iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0

最大的心智模型轉換:Active Record vs Data Mapper

Eloquent 是 Active Record 模式:一個 Model 類別同時扮演「資料」跟「怎麼存取這筆資料」兩個角色,物件自己知道怎麼把自己存進資料庫:

$link = new Link();
$link->code = 'abc123';
$link->original_url = 'https://example.com';
$link->save();   // Model 自己知道怎麼存自己

$link = Link::where('code', 'abc123')->first();   // 查詢邏輯也寫在 Model 上

Spring Data JPA 是 Data Mapper 模式:Entity 純粹只是資料容器,完全不知道怎麼把自己存進資料庫;存取邏輯獨立在另一個 Repository 物件上:

Link link = new Link();
link.setCode("abc123");
link.setOriginalUrl("https://example.com");
linkRepository.save(link);   // 存的動作由 Repository 觸發,不是 Link 自己

Optional<Link> found = linkRepository.findByCode("abc123");   // 查詢邏輯也在 Repository

這不是 Java 比較囉唆而已,是兩邊的架構哲學根本不同:Active Record 讓 Model 兩用、寫起來快,但也讓 Model 承擔太多職責(資料 + 商業邏輯 + 查詢邏輯全部黏在一起,Model 一大就難維護);Data Mapper 把「資料長什麼樣」跟「怎麼存取」拆開,每個 Entity 只單純是資料,查詢/存取的責任交給 Repository,換取的是清楚的職責邊界。這個轉換想通了,後面看到「Entity 上為什麼不能寫查詢方法」這類問題就不會覺得奇怪。

定義 Link Entity

欄位對齊之前 docs/SPEC.md 定的資料模型:

@Entity
@Table(name = "links")
public class Link {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String code;

    @Column(name = "original_url", nullable = false)
    private String originalUrl;

    @Column(name = "owner_id", nullable = false)
    private Long ownerId;

    private Integer clickCount = 0;

    private LocalDateTime expiresAt;

    private LocalDateTime createdAt;

    // getter/setter 省略,之後可以考慮用 Lombok 的 @Getter/@Setter 減少樣板
}

對照 Eloquent:

class Link extends Model
{
    protected $fillable = ['code', 'original_url', 'owner_id', 'expires_at'];

    protected $casts = [
        'expires_at' => 'datetime',
    ];
}

Laravel 用 $fillable 宣告「哪些欄位可以被批量賦值」、$casts 宣告型別轉換;JPA 沒有這兩個概念的直接對應——型別轉換這件事在 JPA 裡是靜態的,欄位宣告成 LocalDateTime 型別,JPA 就知道要怎麼跟資料庫的日期時間型別互轉,不需要額外宣告一份 casts 清單。

要注意:這篇只先定義 Entity 本身的欄位對應,資料表要怎麼被建出來(CREATE TABLE 這件事)留到 Day08(Flyway/Liquibase vs Laravel Migration)講——這篇先讓 Entity 跟 Repository 能動起來,本機開發階段先讓 Hibernate 自動建表就好。

Repository:不用寫實作,靠方法名稱自動生成查詢

Spring Data JPA 最反直覺、也最方便的地方:你只要宣告一個介面,不用寫任何實作,框架會在啟動時自動生成:

public interface LinkRepository extends JpaRepository<Link, Long> {
    Optional<Link> findByCode(String code);
    boolean existsByCode(String code);
}

findByCodeexistsByCode 這些方法完全沒有實作內容,Spring Data JPA 會照方法名稱去解析出對應的 SQL——findByCode 就是 WHERE code = ?,這是靠嚴格的命名規則做到的,方法名稱打錯字或规则不符,程式碼編譯得過,但啟動時會直接噴錯(這點跟 Eloquent 動態方法比起來,算是「錯得早」的例子,呼應 Day02 講過的靜態型別 vs 動態型別的回饋時機差異)。

繼承 JpaRepository<Link, Long> 就已經免費拿到一整組基本 CRUD 方法(savefindByIdfindAlldeleteById⋯),對照 Eloquent 內建的 Model::find()Model::all() 這些靜態方法,效果類似,只是拿到的地方不同——Eloquent 是 Model 類別本身內建,Spring Data JPA 是繼承 JpaRepository 介面免費拿到。

接回 Controller:把 Day05/06 的假資料換成真的

Day05 寫的 store() 方法裡有兩個 TODO,現在可以填上了:

@Service
class LinkService {
    private final LinkRepository linkRepository;

    LinkService(LinkRepository linkRepository) {
        this.linkRepository = linkRepository;
    }

    LinkResponse create(CreateLinkRequest request) {
        Link link = new Link();
        link.setCode("abc123");   // Day14 才會講怎麼真正產生短碼,這裡先沿用假值
        link.setOriginalUrl(request.url());
        link.setOwnerId(1L);      // 30 天內固定是單一使用者,見 architecture-notes.md
        link.setCreatedAt(LocalDateTime.now());

        Link saved = linkRepository.save(link);
        return new LinkResponse(saved.getCode(), "https://short.ly/" + saved.getCode());
    }

    Optional<LinkResponse> findByCode(String code) {
        return linkRepository.findByCode(code)
            .map(link -> new LinkResponse(link.getCode(), "https://short.ly/" + link.getCode()));
    }
}

回傳型別用 Optional<LinkResponse>——這就是 Day02 講過的 Optional,用型別明確表達「這個短碼可能查不到」,呼叫端(Controller)要用 .map()/.orElse() 處理,而不是回傳一個可能是 null 的物件讓呼叫端自己記得檢查。Controller 怎麼接這個 Service(分層架構的完整樣貌),會是 Day09 的主題,這篇先把資料層打通就好。


上一篇
Day6 - 請求驗證 Validation vs Laravel Form Request
下一篇
Day8 - 資料庫遷移 Flyway/Liquibase vs Laravel Migration
系列文
從 Laravel 到 Spring Boot:30 天打造縮網址服務9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言